Skip to content

Oracle: first-class command builder + real statement splitting - #390

Merged
jeremydmiller merged 3 commits into
masterfrom
feat/oracle-command-builder
Jul 26, 2026
Merged

Oracle: first-class command builder + real statement splitting#390
jeremydmiller merged 3 commits into
masterfrom
feat/oracle-command-builder

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Background

Oracle is the one Weasel provider whose driver cannot execute several statements from a single command. Confirmed against ODP.NET 23.7.0:

CanCreateBatch = False
CreateBatch threw: NotSupportedException

Its bind marker is also : rather than @. So a database-agnostic consumer building a batch against the dialect-neutral DbCommandBuilder emits SQL Oracle rejects outright (ORA-00933 / ORA-00936), and there was no way to hand back an Oracle-shaped builder instead — Weasel.Oracle.CommandBuilder is a sibling of DbCommandBuilder, not a subclass, so it can't be returned where one is expected.

This is what's currently blocking Wolverine's durability agent on Oracle (JasperFx/wolverine#3614).

Weasel.Core

  • AddParameter / AddNamedParameter are now virtual, so a provider can normalize values on every path that binds one.
  • New StartNewCommand() / CommandCount / CompileCommands() on CommandBuilderBase, defaulting to exactly today's behaviour — one command holding every statement. A consumer can now mark statement boundaries unconditionally and let the provider decide whether they mean anything. Postgres and SQL Server keep concatenating and pay nothing for it.
  • DbCommandBuilder gains a protected constructor taking the bind marker.

Nothing here changes existing behaviour for any current caller; Weasel.Core.ICommandBuilder is deliberately untouched so Marten is unaffected.

Weasel.Oracle

  • New OracleDbCommandBuilder — a DbCommandBuilder that emits : markers, sets BindByName, types parameters through OracleProvider rather than the generic DbType mapping (which resolves Guid to DbType.Object, which ODP.NET rejects), converts GuidRAW(16) and boolNUMBER(1), and splits at every StartNewCommand() boundary into one OracleCommand per statement, each carrying only the parameters its own statement bound.
  • New Weasel.Oracle.ICommandBuilder, with CommandBuilder implementing it — parity with the Postgres and SQL Server providers.
  • Bug fix: CommandBuilder's Guid → RAW conversion was a new member rather than an override. Since the base class routes all of its typed AppendParameter overloads through AddParameter, the hiding member was bypassed on every one of those paths and a raw Guid reached OracleParameter.Value. Now an override, and it covers bool too.
  • Bug fix: CommandBuilder now sets BindByName, which ODP.NET otherwise leaves off — it binds positionally by default, silently mis-binding any command whose parameters weren't added in the same order they appear in the SQL.

Validation

Suite Result
Weasel.Oracle.Tests (full, live Oracle) 181/181
Weasel.Core.Tests 21/21
Weasel.Postgresql.Tests builder + batcher (live PG) 18/18
Weasel.SqlServer.Tests builder + batcher (live SQL Server) 14/14

Integration tests execute a real multi-statement batch against Oracle in a single transaction and assert the Guid/bool round trip, read results back per-command, and include a guard that will fail if ODP.NET ever grows DbBatch support and makes the splitting unnecessary.

🤖 Generated with Claude Code

jeremydmiller and others added 3 commits July 26, 2026 12:46
Oracle is the one Weasel provider whose driver cannot execute several
statements from a single command: ODP.NET reports CanCreateBatch = false,
CreateBatch() throws NotSupportedException, and a semicolon-separated
command fails with ORA-00933/ORA-00936. Its bind marker is also `:` rather
than `@`. Consumers that build batches against the dialect-neutral
DbCommandBuilder therefore emitted SQL that Oracle rejects outright, and had
no way to hand back an Oracle-shaped builder instead -- Weasel.Oracle's
CommandBuilder is a sibling of DbCommandBuilder, not a subclass.

Weasel.Core:

- AddParameter / AddNamedParameter are now virtual, so a provider can
  normalize values on every path that binds one.
- New StartNewCommand() / CommandCount / CompileCommands() on
  CommandBuilderBase, defaulting to exactly today's behaviour: one command
  holding every statement. A consumer can now mark statement boundaries
  unconditionally and let the provider decide whether they mean anything.
  Postgres and SQL Server keep concatenating and pay nothing.
- DbCommandBuilder gains a protected constructor taking the bind marker.

Weasel.Oracle:

- New OracleDbCommandBuilder: a DbCommandBuilder that emits `:` markers,
  sets BindByName, types parameters through OracleProvider rather than the
  generic DbType mapping (which resolves Guid to DbType.Object and is
  rejected), converts Guid to RAW(16) and bool to NUMBER(1), and splits at
  every StartNewCommand() boundary into one OracleCommand per statement,
  each carrying only the parameters its own statement bound.
- New Weasel.Oracle.ICommandBuilder, and CommandBuilder now implements it,
  bringing Oracle to parity with Postgres and SQL Server.
- CommandBuilder's Guid conversion was a `new` member, so the base class's
  typed AppendParameter overloads all routed straight past it through
  AddParameter and handed a raw Guid to OracleParameter.Value. It is now an
  override, and covers bool as well.
- CommandBuilder sets BindByName, which ODP.NET otherwise leaves off.

Integration tests execute a real multi-statement batch against Oracle in a
single transaction and assert the Guid/bool round trip, plus a guard that
fails if ODP.NET ever grows DbBatch support and makes the splitting moot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…parator

Two things surfaced while wiring Wolverine's durability agent onto this.

Callers terminate each statement with a trailing semicolon, because that is
what the providers that concatenate everything into one command need. Oracle
executes one statement per command, where a trailing semicolon is ORA-00911,
so OracleDbCommandBuilder now strips it when it closes a statement rather
than making every caller branch on the provider.

Callers that hand-write a bind marker for a named parameter -- rather than
going through AppendParameter, which writes one for them -- had no way to ask
what this dialect's marker is, so they hard-coded `@`. CommandBuilderBase now
exposes ParameterPrefix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…es it

AddNamedParameter finds-or-adds, so a named parameter referenced by more
than one statement in a batch is only ever created once, and the index range
that decides which split command owns it put it on exactly one of them. The
others bound nothing and failed at execution.

A parameter now belongs to a split command if it was bound while that
statement was open *or* if the statement's SQL names it. Matching is on the
whole token, so :p1 is not treated as a reference to :p11.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jeremydmiller
jeremydmiller merged commit d0becaf into master Jul 26, 2026
23 checks passed
@jeremydmiller
jeremydmiller deleted the feat/oracle-command-builder branch July 26, 2026 18:55
jeremydmiller added a commit to JasperFx/wolverine that referenced this pull request Jul 26, 2026
#3659)

* fix(oracle): run the durability agent through the shared batching mechanics

The durability agent batches its whole recovery operation set into one command
builder and executes it. Oracle's message store handed back the generic
DbCommandBuilder, which emits `@` bind markers and concatenates every statement
into a single command. ODP.NET rejects both -- it has no DbBatch support at all
(CanCreateBatch is false, CreateBatch throws) and will not execute several
statements from one command -- so the agent threw ORA-00933 / ORA-00936 /
ORA-03405 on every sweep and nothing persisted in the inbox or outbox was ever
recovered.

Rather than give Oracle a bespoke execution path, this teaches the shared
batching mechanics about statement boundaries and lets the provider decide what
they mean. DatabaseOperationBatch now marks a boundary before each operation and
executes whatever CompileCommands() hands back. On every provider whose driver
can execute several statements from one command, StartNewCommand() is a no-op,
CompileCommands() returns a single command, and the behaviour is byte for byte
what it was. Oracle returns Weasel.Oracle's OracleDbCommandBuilder, which emits
`:` markers, types parameters through OracleProvider, and splits.

Three things the semicolon-splitting approach would have missed:

- Four operations write more than one statement each (both ReleaseOrphaned
  variants, MoveReplayableErrorMessagesToIncoming, and PersistNodeRecord's
  insert per event). Splitting per operation is not enough, so those now mark
  their internal boundaries explicitly.
- MoveReplayableErrorMessagesToIncoming binds :replayable from two different
  statements. AddNamedParameter finds-or-adds, so it exists once and has to be
  bound to both split commands.
- The same operation hard-coded `@replayable` in its SQL text, which no
  provider-neutral consumer should do. It reads the marker off the builder now.

Also documents the real reason OracleMessageStore.EnqueueAsync is a no-op: it
implements IMessageDatabase directly rather than deriving from MessageDatabase,
so it has no DatabaseBatcher. The durability agent does not use that path.

Adds Pedro Andrade's coverage from #3615, retargeted at the new design and
extended with an end-to-end assertion that the real recovery batch runs against
a real Oracle database -- red-verified as ORA-03405 before this change.

Fixes #3614.

Co-Authored-By: Pedro Henrique Andrade Siqueira <pedroandrade03@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(deps): Weasel 9.19.0

Brings in JasperFx/weasel#390 -- Weasel.Oracle's OracleDbCommandBuilder and the
StartNewCommand()/CompileCommands() statement-boundary hooks on CommandBuilderBase
that the Oracle durability fix is built on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Pedro Henrique Andrade Siqueira <pedroandrade03@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant